﻿using System;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using Relay.Core.Utility;

namespace Relay.Core.Sockets {
    /// <summary>
    /// Represents a single client connection to a remote server using the TCP protocol. 
    /// </summary>
    public class TcpClientSocket {
        private const int BufferSize = 512;

        #region Structs
        private struct SocketThreadInfo {
            public Socket ClientSocket;
            public IPEndPoint EndPoint;

            public SocketThreadInfo(Socket socket, IPEndPoint endPoint) {
                ClientSocket = socket;
                EndPoint = endPoint;
            }
        }
        #endregion

        #region Fields
        private Socket _clientSocket = null;
        private string _remoteHost = "localhost";
        private int _remotePort = 80;
        private bool _disconnectLocally = false;

        // Storing SynchronizationContext.Current allows other threads (i.e. socket processing threads) to raise
        // events on the thread that created the socket using the SynchronizationUtility.SafeInvoke extension method.
        private readonly SynchronizationContext _syncContext = SynchronizationContext.Current;
        #endregion

        #region Events
        /// <summary>
        /// Occurs when the socket establishes a connection.
        /// </summary>
        public event EventHandler<EventArgs> Connected;

        /// <summary>
        /// Occurs when the socket's connection is terminated.
        /// </summary>
        public event EventHandler<TcpSocketDisconnectedEventArgs> Disconnected;

        /// <summary>
        /// Occurs when the socket receives data from the remote server.
        /// </summary>
        public event EventHandler<TcpSocketDataReceievedEventArgs> DataReceieved;

        /// <summary>
        /// Occurs when the socket did not connect to its endpoint successfully;
        /// </summary>
        public event EventHandler<TcpSocketConnectFailedEventArgs> ConnectFailed;
        #endregion

        #region Constructor
        /// <summary>
        /// Construct a default socket.
        /// </summary>
        public TcpClientSocket() : this("localhost", 80) { }

        /// <summary>
        /// Construct a socket given a remote host name and port number.
        /// </summary>
        /// <param name="hostName">The host name of the remote server.</param>
        /// <param name="port">The port on which to connect on the remote server.</param>
        public TcpClientSocket(string hostName, int port) {
            _remoteHost = hostName;
            _remotePort = port;
        }
        #endregion

        #region Properties
        /// <summary>
        /// Gets a boolean indicating whether or not the client socket is connected.
        /// </summary>
        public bool IsConnected {
            get { return _clientSocket.Connected; }
        }

        /// <summary>
        /// Gets or sets the remote host to which this client socket's connection is directed.
        /// </summary>
        public string RemoteHost {
            get { return _remoteHost; }
            set {
                if (_clientSocket != null && _clientSocket.Connected) {
                    throw new InvalidOperationException("The remote host cannot be changed while the socket is " +
                                                        "connected.");
                }
                _remoteHost = value;
            }
        }

        /// <summary>
        /// Gets or sets the port on which the remote host is listening.
        /// </summary>
        public int RemotePort {
            get { return _remotePort; }
            set {
                if (_clientSocket != null && _clientSocket.Connected) {
                    throw new InvalidOperationException("The remote port cannot be changed while the socket is " +
                                                        "connected.");
                }
                _remotePort = value;
            }
        }
        #endregion

        #region Public Methods
        /// <summary>
        /// Start the process of connecting to the remote server specified by the <c>RemoteHost</c> and
        /// <c>RemotePort</c> properties.
        /// </summary>
        public void Connect() {
            if (_clientSocket != null && _clientSocket.Connected) {
                throw new InvalidOperationException("The socket is already connected");
            }

            _disconnectLocally = false;

            _clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

            IPEndPoint endPoint = null;
            try {
                endPoint = new IPEndPoint(GetIpFromHostName(_remoteHost), _remotePort);
            } catch (UnresolvedHostNameException e) {
                OnConnectFailed(new TcpSocketConnectFailedEventArgs(e));
                return;
            }

            // Start the process of connecting on a new thread
            Thread newThread = new Thread(new ParameterizedThreadStart(WorkThread));
            newThread.IsBackground = true;
            newThread.Start(new SocketThreadInfo(_clientSocket, endPoint));
        }

        /// <summary>
        /// Disconnect from the remote server.
        /// </summary>
        public void Disconnect() {
            if (_clientSocket == null || !_clientSocket.Connected)
                return;

            _disconnectLocally = true;
            _clientSocket.Shutdown(SocketShutdown.Both);
            _clientSocket.Close();
        }

        /// <summary>
        /// Sends the specified string of text to the remote server.
        /// </summary>
        /// <param name="text">The text to send.</param>
        public void SendText(string text) {
            if (_clientSocket == null || !_clientSocket.Connected)
                throw new InvalidOperationException("The socket is not connected to a remote server.");

            _clientSocket.Send(Encoding.ASCII.GetBytes(text));
        }
        #endregion

        #region Private Methods
        /// <summary>
        /// Attempt to resolve the specified host name into an IP address.
        /// </summary>
        /// <param name="hostName">The remote host name to resolve.</param>
        /// <returns>An <see cref="System.Net.IPAddress"/> object representing the resolved IP address.</returns>
        private IPAddress GetIpFromHostName(string hostName) {
            try {
                IPHostEntry hostEntry = Dns.GetHostEntry(hostName);

                // On certain machines, IPv6 is favoured over IPv4, so we must filter those out, otherwise it's
                // possible that in certain situations (like resolving localhost) that the first address returned will
                // be IPv6.
                IPAddress[] addresses = hostEntry.AddressList
                                                 .Where(x => x.AddressFamily == AddressFamily.InterNetwork)
                                                 .ToArray();

                return addresses[0];
            } catch (SocketException) {
                throw new UnresolvedHostNameException(String.Format("The host name '{0}' could not be resolved",
                                                                    hostName));
            }
        }

        /// <summary>
        /// Runs the actual socket processing code on a new thread.
        /// </summary>
        /// <param name="param">Information that the thread uses.</param>
        private void WorkThread(object param) {
            SocketThreadInfo info = (SocketThreadInfo)param;

            if (info.ClientSocket == null || info.EndPoint == null)
                throw new NullReferenceException("Incomplete SocketThreadInfo structure.");

            Socket socket = info.ClientSocket;
            try {
                socket.Connect(info.EndPoint);
            } catch (Exception e) {
                OnConnectFailed(new TcpSocketConnectFailedEventArgs(e));
            }

            if (!socket.Connected)
                return;

            // Now we're now connected
            OnConnected(new EventArgs());

            try {
                int bytesReceived = 0;

                for (; ; ) {
                    while (!socket.Poll(-1, SelectMode.SelectRead)) ;

                    byte[] buffer = new byte[BufferSize];
                    bytesReceived = socket.Receive(buffer, BufferSize, SocketFlags.None);

                    if (bytesReceived == 0) {
                        OnDisconnected(new TcpSocketDisconnectedEventArgs(TcpSocketDisconnect.RemoteHostClosed));
                        break;
                    } else {
                        Array.Resize(ref buffer, bytesReceived); // Remove excess nulls
                        OnDataReceieved(new TcpSocketDataReceievedEventArgs(buffer, bytesReceived));
                    }
                }
            } catch (ObjectDisposedException) {
                // _disconnectLocally will be true if the socket was closed locally
                OnDisconnected(new TcpSocketDisconnectedEventArgs(_disconnectLocally ?
                    TcpSocketDisconnect.LocalHostClosed : TcpSocketDisconnect.ConnectionBroken));
            }
        }
        #endregion

        #region Protected Methods
        protected virtual void OnConnected(EventArgs e) {
            if (Connected != null) Connected.SafeInvoke(_syncContext, this, e);
        }
        protected virtual void OnDisconnected(TcpSocketDisconnectedEventArgs e) {
            if (Disconnected != null) Disconnected.SafeInvoke(_syncContext, this, e);
        }
        protected virtual void OnDataReceieved(TcpSocketDataReceievedEventArgs e) {
            if (DataReceieved != null) DataReceieved.SafeInvoke(_syncContext, this, e);
        }
        protected virtual void OnConnectFailed(TcpSocketConnectFailedEventArgs e) {
            if (ConnectFailed != null) ConnectFailed.SafeInvoke(_syncContext, this, e);
        }
        #endregion
    }
}

